Skip to content

feat(automation): add Linux systemd user-timer scheduler#29

Open
vsilv wants to merge 1 commit into
AlmanacCode:mainfrom
vsilv:feat/linux-systemd-scheduler
Open

feat(automation): add Linux systemd user-timer scheduler#29
vsilv wants to merge 1 commit into
AlmanacCode:mainfrom
vsilv:feat/linux-systemd-scheduler

Conversation

@vsilv

@vsilv vsilv commented Jul 13, 2026

Copy link
Copy Markdown

Problem

Scheduled automation (setup, automation, and config-driven reconcile) only
works on macOS. The composition root hard-codes LaunchdSchedulerAdapter, and
the automation domain model leaks launchd vocabulary (plist_path). On Linux,
setup/automation fail because there is no scheduler adapter and launchctl
is absent.

What this does

Adds Linux support behind the existing SchedulerAdapter port, using systemd
--user timers — the closest analogue to launchd LaunchAgents (per-user,
persistent, and rich enough to fill the existing ScheduledJobStatus model).

  • Select the scheduler by platform. default_scheduler_adapter() returns
    the systemd adapter on Linux and the launchd adapter elsewhere, wired into the
    composition root (app.py). The explicit adapters.scheduler injection path
    is unchanged, so tests still inject fakes.
  • De-launchd the domain model. plist_path becomes manifest_path, and the
    manifest location is computed per platform: a launchd plist under
    ~/Library/LaunchAgents on macOS, a systemd .timer under the user unit
    directory on Linux (honoring XDG_CONFIG_HOME when it is an absolute path).
    The macOS-only PATH fallbacks no longer leak onto Linux.
  • SystemdSchedulerAdapter. Writes a .service + .timer unit pair,
    enables and (re)starts the timer through systemctl --user, and reads status
    back from systemctl show. OnActiveSec=0 mirrors launchd RunAtLoad;
    OnUnitActiveSec carries the interval. is_loaded gates on ActiveState, so
    a stopped or disabled timer is not reported as loaded. The systemctl runner
    is injectable so the adapter is unit-testable without touching the real user
    manager.
  • Shared helper. The process-error helper moves to scheduler/process.py so
    neither adapter depends on the other.
  • Provider-neutral automation status output and updated docs (README support
    matrix, automation architecture notes).

Rejected alternative: cron

cron was considered and rejected. crontab exposes no per-job status, run
count, exit code, or PID, so ScheduledJobStatus would be half-empty and behave
differently from macOS. systemd show maps cleanly onto the existing model.

Behavior / contract change

automation status (human output and --json) now reports manifest_path
instead of the launchd-specific plist_path; the human labels are manifest
and scheduler loaded. This is a deliberate, provider-neutral rename.

Two systemd choices are intentional and documented in the plan:

  • Persistent=true is omitted — per systemd.timer(5) it only affects
    OnCalendar= timers, so it would be inert config on a monotonic timer.
  • run_count is not reported on Linux — NRestarts counts Restart= restarts,
    always 0 for a oneshot service, so reporting it would be misleading. macOS
    keeps launchd's runs.

Testing

  • uv run pytest — 477 passed. uv run ruff check . — clean. uv build — ok.
  • Real end-to-end run on Ubuntu (systemd user manager running): enabling an
    automation task through the CLI installed a real timer; automation status
    reported it installed, scheduler loaded: yes, with the correct interval
    and state; the scheduled run fired and exited 0; is_loaded correctly
    returned false after the timer was stopped; uninstall removed both unit files
    and left no residual units in the user manager.

Scheduled automation (setup, `automation`, and config-driven reconcile)
previously worked only on macOS: the composition root hard-coded the launchd
adapter and the domain model leaked launchd vocabulary (`plist_path`). This
adds Linux support behind the existing `SchedulerAdapter` port.

- Generalize the automation domain model: `plist_path` becomes `manifest_path`,
  and the manifest location is computed per platform (launchd plist on macOS,
  a systemd `.timer` under the user unit directory on Linux, honoring
  `XDG_CONFIG_HOME` when it is an absolute path).
- Add `SystemdSchedulerAdapter`: writes a `.service` + `.timer` unit pair,
  enables and (re)starts the timer through `systemctl --user`, and reads status
  back from `systemctl show`. `OnActiveSec=0` mirrors launchd `RunAtLoad`;
  `OnUnitActiveSec` carries the interval. `is_loaded` gates on `ActiveState`, so
  a stopped or disabled timer is not reported as loaded. The `systemctl` runner
  is injectable so the adapter is unit-testable.
- Select the scheduler adapter by platform in the composition root via
  `default_scheduler_adapter()`; the explicit adapter-injection path is
  unchanged.
- Provider-neutral `automation status` output (`manifest` / `scheduler loaded`).
- Split the shared process-error helper into `scheduler/process.py` so neither
  adapter depends on the other.
- Docs: README support matrix and the automation architecture notes.

Note: `automation status` (human and `--json`) now reports `manifest_path`
instead of the launchd-specific `plist_path`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant